Skip to content

Convert bucket_key/requested_bucket_key to real foreign keys - #11871

Merged
nbudin merged 4 commits into
mainfrom
11868-bucket-key-foreign-keys
Aug 4, 2026
Merged

Convert bucket_key/requested_bucket_key to real foreign keys#11871
nbudin merged 4 commits into
mainfrom
11868-bucket-key-foreign-keys

Conversation

@nbudin

@nbudin nbudin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Converts bucket_key/requested_bucket_key string columns on signups, signup_requests, and signup_ranked_choices into real bucket_id/requested_bucket_id foreign keys referencing registration_policy_buckets.id, so bucket references are enforced at the database level instead of matched by a free-text string.

  • Adds bucket_id/requested_bucket_id FK columns, backfills them from the old string columns (matching by key, scoped to the correct registration policy), then drops the old columns entirely.
  • signup_changes (an audit-log table) gets FK columns that nullify on bucket delete (rather than the restrictive default), plus new bucket_name/requested_bucket_name snapshot columns, so historical audit rows keep showing a bucket's name even after that bucket is later deleted.
  • All application code (models, services, GraphQL, presenters, Liquid drops, notifiers) is converted to use bucket_id as the source of truth.
  • SignupBucketFinder and RegistrationPolicyBucket#signup_definitely_occupies_slot_in_bucket? deliberately stay key-based internally — they're shared with EventChangeRegistrationPolicyService's simulation of a registration policy change against a detached, not-yet-persisted candidate policy, whose buckets have no id yet. Key is the only identity valid on both a real signup and a hypothetical candidate bucket.
  • EventChangeRegistrationPolicyService (the Changes to bucket keys can invalidate signups, signup requests, and queue items #11229 remap-on-policy-change logic) is reordered: any FK reference to a bucket about to be destroyed is nulled out before the destroy (the FKs on signups/signup_requests/signup_ranked_choices don't nullify on delete, so leaving a reference in place would raise a foreign key violation), then re-resolved to its final value once the surviving or newly-created bucket is persisted with a real id.
  • GraphQL mutations/types keep bucket_key/requested_bucket_key functional but deprecated, and add bucket/requestedBucket object fields (plus bucketId/toBucketId/fromBucketId input args) so existing clients keep working unchanged. Added descriptions to the pre-existing GraphQL fields/arguments this PR touched that were missing one.
  • Extended .rubocop_todo.yml for pre-existing style debt (missing super calls, long parameter lists, Time.zone usage, etc.) in files this migration happens to touch but doesn't otherwise need to change.

Known limitation

A small number of historical requested_bucket_key values (well under 1%) referenced buckets already removed from their event's current registration policy before this migration ran, and couldn't be backfilled to a real id. That preference data was already unusable (the bucket it pointed at doesn't exist anymore), so this is a no-op in practice.

Follow-up

Filed #11870 to track converting the remaining internal key-based identity (used for registration-policy-edit correlation and simulation) to id-based once bucket key itself is no longer needed anywhere — out of scope here since it changes the correlation identifier the registration-policy-edit UI has to round-trip.

Test plan

  • Full Ruby test suite passes (1142 tests, 0 failures)
  • yarn run tsc --noEmit passes
  • Frontend test suite passes (183 tests)
  • GraphQL schema regenerated (bin/rails graphql:update)
  • Manually verified backfill correctness against production-shaped data (spot-checked orphaned/unmatched rows)

Fixes #11868

🤖 Generated with Claude Code

Adds bucket_id/requested_bucket_id FK columns (backfilled from the old
string columns, then drops them) to signups, signup_requests, and
signup_ranked_choices, referencing registration_policy_buckets.id
directly instead of matching by key. signup_changes (an audit table)
gets FK columns that nullify on bucket delete, plus bucket_name/
requested_bucket_name snapshot columns so historical audit rows keep
showing a bucket's name even after it's deleted.

All application code (models, services, GraphQL, presenters, Liquid
drops, notifiers) is converted to use bucket_id as the source of
truth. SignupBucketFinder and RegistrationPolicyBucket#signup_
definitely_occupies_slot_in_bucket? deliberately stay key-based
internally, since they're also used to simulate a registration policy
change against a detached, not-yet-persisted candidate policy whose
buckets have no id yet.

EventChangeRegistrationPolicyService (the #11229 remap-on-policy-
change logic) is reordered: FK references to a bucket about to be
destroyed are nulled out before the destroy (avoiding a foreign key
violation) and re-resolved to their final value once the surviving or
newly-created bucket is persisted.

GraphQL mutations/types keep bucket_key/requested_bucket_key
functional but deprecated, and add bucket/requestedBucket object
fields (and bucket_id/to_bucket_id/from_bucket_id args) so existing
clients don't break. Added descriptions to the pre-existing GraphQL
fields/arguments this touched that didn't already have one.

Extended .rubocop_todo.yml for pre-existing style debt (missing
super calls, parameter list length, Time.zone usage, etc.) in files
this migration happens to touch but doesn't otherwise need to change.

Note: a small number of historical requested_bucket_key values (a
fraction of a percent) referenced buckets already removed from their
event's current policy before this migration and could not be
backfilled to a real id -- this data was already unusable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nbudin nbudin added database enhancement minor Bumps the minor version number on release labels Aug 3, 2026
@nbudin

nbudin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Review: PR #11871 — Convert bucket_key/requested_bucket_key to real foreign keys

Note: 11868 is the issue number; the PR for this branch is #11871. Reviewed that.
82 files, +2530/−852. Reviewed at head bd3b32ee0b.

Overview

Replaces the free-text bucket_key/requested_bucket_key string columns on signups,
signup_requests, signup_ranked_choices, and signup_changes with bucket_id/requested_bucket_id
FKs to registration_policy_buckets, backfills them by key, and drops the old columns.
signup_changes gets ON DELETE SET NULL plus bucket_name/requested_bucket_name snapshot columns
so audit rows survive bucket deletion. GraphQL keeps bucketKey/requestedBucketKey working but
deprecated and adds object/id fields, so the frontend needs no flag day.

The overall design is sound, and several decisions are well-argued in the comments: keeping
SignupBucketFinder and signup_definitely_occupies_slot_in_bucket? key-based for detached candidate
policies, snapshotting names on the audit table, and JSON-encoding SignupMoveResult#id now that names
are free text. The reordering of EventChangeRegistrationPolicyService around the destroy is the right
shape.

That said, there are four correctness issues and a set of N+1s introduced by turning column reads into
association reads.

Correctness

1. Policy change now fails when a removed bucket maps to "no preference" and the new policy has no flex bucket (regression)

apply_bucket_key_mappings moved from before the simulation to after it, replaced during simulation
by pending_requested_key_overrides. But that hash records only truthy mappings:

# app/services/event_change_registration_policy_service.rb:216
hash[signup.id] = to_key if to_key

So a to_key: nil mapping ("clear the preference") produces no override, and simulate_signup
falls back to signup.requested_bucket_key — the removed bucket's key.
SignupBucketFinder#prioritized_buckets_with_requested_bucket then resolves requested_bucket to nil
against the new policy and returns just [anything_bucket], instead of
prioritized_buckets_without_requested_bucket (all counted, limited buckets). With no flex bucket in
the new policy, find_bucket returns nil and the signup is reported immovable.

Verified with a probe test (removing unlimited, mapping to nil, new policy = dogs only, no flex):

  • this branch: Signup for … would no longer fit, whole policy change fails
  • main: succeeds, signup moved to dogs, requested_bucket_key cleared

The existing tests at test/services/event_change_registration_policy_service_test.rb:367-386 cover
to_key: nil, but new_registration_policy (line 9-17) always contains an anything bucket, which
masks it.

Fix: distinguish "no override" from "override to nil" — key the hash on mapping existence and read it
with key?:

hash[signup.id] = to_key if removed_bucket_ids.include?(signup.requested_bucket_id) &&
                            bucket_key_mappings.key?(removed_buckets_by_id[signup.requested_bucket_id]&.key)
# ...
requested_key = overrides.key?(signup.id) ? overrides[signup.id] : signup.requested_bucket_key

Please add a test with a flex-less target policy.

2. Admin "Bucket" filter on the run signups table silently returns zero rows

app/presenters/tables/signups_table_results_presenter.rb:77 changed column_filter :bucket_key
column_filter :bucket_id, but RunSignupsTable.tsx's BucketFilter still submits keys and was not
updated by this PR:

// app/javascript/EventsApp/SignupAdmin/RunSignupsTable.tsx:92
value: bucket.key,

Signup.where(bucket_id: ["flex"]) compiles to WHERE "signups"."bucket_id" IN (NULL) (verified via
to_sql) — no error, no rows. The filter appears to work and returns nothing.

Either send bucket.id from BucketFilter, or keep the presenter resolving keys → ids in
apply_filter.

Relatedly, sql_order changed from bucket_key to bucket_id, so the Bucket column now sorts by
bucket row id (creation order) rather than key. Probably an improvement, but it's a silent user-visible
change worth a line in the PR body.

3. ForceConfirmSignup can 500

Both arguments became optional with no guard:

# app/graphql/mutations/force_confirm_signup.rb
argument :bucket_id, ID, required: false
argument :bucket_key, String, required: false, deprecation_reason: "Use bucketId instead"
# ...
signup.update!(state: "confirmed", bucket_id: bucket.id, ...)  # NoMethodError if bucket is nil

bucket_key was required: true before, so omitting both is newly schema-legal and yields a
NoMethodError on nil rather than a validation error. UpdateSignupBucket got a proper
GraphQL::ExecutionError guard — mirror it here. (Note the existing frontend sends
bucketKey: … ?? '', which already reaches the nil-bucket path.)

Same shape in UpdateSignupBucket#resolve: signup.run.bucket_full?(bucket_id) runs before the
update, and bucket_with_id returns nil for an id outside the run's policy → nil.full?. The lookup
being policy-scoped does correctly prevent cross-convention writes, so this is a 500-vs-error-message
issue, not a security one.

4. Missed conversion in notifier_preview_factory.rb

# app/notifiers/notifier_preview_factory.rb:115
SignupMoveResult.new(convention.signups.first.id, "confirmed", "flex", "waitlisted", nil)

Still passes a bucket key in the bucket_id position. The :prev_bucket_key:prev_bucket_id
case just above was updated; this one wasn't. Notifier previews for signup-move templates will render
an empty bucket name.

Performance: N+1s introduced

Signup#bucket_key/#requested_bucket_key went from column reads to association reads, and nothing
preloads :bucket anywhere in app/. Measured, not inferred:

SignupBucketFinder construction: 10 signups → 10 queries against registration_policy_buckets.
FakeSignup.from_signup reads both bucket_key and requested_bucket_key for every other_signup.
EventSignupService#bucket_finder passes run.signups.to_a, so every signup attempt on a run with N
signups now issues N–2N extra queries. This is the signup hot path. Same in
EventChangeRegistrationPolicyService#simulate_signups (plus
signups.partition { … !signup.bucket_key }).

RegistrationPolicyBucket#signup_definitely_occupies_slot_in_bucket? compares
signup.bucket_key == key, so available_slots/full?/has_available_slots? — and therefore
Run#full?, Run#available_slots_by_bucket_id, the schedule grid, and the capacity graphs — now issue
a query per signup where they read a column before. Cheapest fix that preserves the detached-policy
contract: compare signup.bucket_id == id when id is present, falling back to key only for
FakeSignup/unpersisted buckets.

GraphQL: Types::SignupType adds :requested_bucket to association_loaders but resolves
bucket via object.bucket — a query per signup on every signup table/list.
Types::SignupChangeType has the same gap (object.bucket in both bucket and exposed_bucket?,
:bucket absent from its association_loaders). Also
EventVacancyFillService#signup_can_fill_bucket_vacancy? (signup.bucket.nil?) and
Signup#log_signup_change! (bucket&.name, requested_bucket&.name).

None of these are hard to fix, but they're spread across hot paths, and there are no query-count
assertions in the suite to catch them.

Deploy risks

In-flight jobs will fail during the deploy window. Signups::WithdrawalNotifier and
WithdrawConfirmationNotifier renamed a required kwarg (prev_bucket_key:prev_bucket_id:), and
Notifier#deliver_later enqueues initializer_options as job args. Jobs enqueued by the old code
(5-second delay, plus any retry backlog) will raise
ArgumentError: unknown keyword :prev_bucket_key; missing keyword :prev_bucket_id.
SignupMoveResultSerializer#deserialize has the same shape — it reads hash[:bucket_id], so old
payloads silently lose bucket info. Suggest accepting both spellings for one release, or draining the
queue before cutover.

Both migrations ship in one release and drop the string columns immediately. Any old container
still serving while migrations run will 500 on signups.bucket_key. The issue explicitly floated a
phased dual-write as the safer option; the PR body doesn't say why it was dropped. If the deploy is
single-container stop-then-start this is fine — worth stating explicitly either way.

Backfill is unbatched. UPDATE signups SET bucket_id = … FROM runs JOIN … rewrites every row in
signups and signup_changes in one statement inside the migration transaction, and add_reference
builds its indexes non-concurrently. There's no strong_migrations in the Gemfile to flag this. For
production-sized signup_changes, consider disable_ddl_transaction! + algorithm: :concurrently and
a batched backfill.

Smaller notes

  • clear_references_to_removed_buckets nulls requested_bucket_id for all signups on the event's
    runs, but apply_requested_bucket_mappings_for_signups(all_signups) only re-resolves non-withdrawn
    ones (all_signups excludes withdrawn). Withdrawn signups' preferences are now silently dropped on
    any bucket removal, even with a mapping supplied. Previously they were left stale. Likely harmless —
    but undocumented.
  • SignupMoveResult#id Base64+JSON is the right call given free-text names, but
    SignupMoveResult.find now raises on any old-format id. No in-repo callers of the GlobalID path, so
    low risk; worth confirming none are persisted anywhere.
  • ForceConfirmSignup uses signup.run.registration_policy in one branch and
    signup.run.event.registration_policy in the other — equivalent, but pick one.
  • EventSignupService#requested_bucket memoizes after the early return unless, so a nil result
    re-queries on each call. Cosmetic.
  • The synthetic id: 'signups' in useEventForm.tsx is safe server-side (build_from_hash strips
    id via IGNORED_HASH_KEYS), but it's a string in an ID! field — a comment would help the next
    reader.

Test coverage gaps

  • No case for to_key: nil against a policy without a flex bucket (finding 1 — every existing case
    has one).
  • No coverage of the signups-table bucket filter (finding 2).
  • No coverage of ForceConfirmSignup with neither argument (finding 3).
  • No query-count assertions anywhere in the signup path, which is why the N+1s slipped through a green
    suite.

Verdict

The data model change and the compatibility strategy are right, and the tricky ordering in
EventChangeRegistrationPolicyService is mostly handled thoughtfully. I'd hold merge on findings 1–3
(all small, localized fixes plus tests), decide explicitly on the notifier-kwarg compatibility shim
before deploy, and either fix the N+1s here or file them as an immediate follow-up — finding 6 in
particular touches the schedule grid, which is one of the heaviest pages in the app.


Review written by Claude

Addresses findings from a review of #11871: a policy-change simulation
bug where clearing a requested bucket on a removed bucket leaked the
old key back in, a bucket_key/bucket_id mismatch between the admin
signups filter and its presenter, two mutations that could 500 on a
nil/invalid bucket instead of raising a clean GraphQL error, a stale
bucket key in the notifier preview factory, and several N+1s introduced
by bucket_key/requested_bucket_key becoming association reads with
nothing preloading the association.

Also splits the bucket_id backfill migration from the legacy column
drop so they can ship in separate releases (drop filed as #11872),
changes the admin signups table's Bucket column to sort by
case-insensitive bucket name instead of bucket row id, and fixes
pre-existing rubocop/eslint debt (a shadowed Lint/MissingSuper entry
in .rubocop_todo.yml, missing `id` selections on several buckets{}
queries) uncovered while touching these files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nbudin

nbudin commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Fixes for review findings

Addressed all four correctness bugs and the N+1s from the review, plus the deploy-risk and smaller-notes items, in 6dcb4dfef4.

Correctness

  1. Policy-change nil-mapping regressionEventChangeRegistrationPolicyService::SignupSimulator now distinguishes "no override" from "override to nil" (via a NO_OVERRIDE sentinel and pending_requested_key_overrides keying on mapping presence), so clearing a preference on a removed bucket no longer falls back to the removed bucket's key. Added a test with a flex-less target policy (event_change_registration_policy_service_test.rb) that reproduces the original bug and fails without the fix.
  2. Admin bucket filterRunSignupsTable.tsx's BucketFilter now submits bucket.id instead of bucket.key (added id to the buckets{} selection everywhere in queries.graphql, not just the one query). Added test/presenters/tables/signups_table_results_presenter_test.rb covering the filter. Also, per an additional request, changed the Bucket column's sort from bucket_id to case-insensitive bucket name (left-joined so unassigned signups still appear).
  3. ForceConfirmSignup/UpdateSignupBucket 500s — both now raise GraphQL::ExecutionError for a missing/invalid bucket instead of NoMethodError on nil; also unified ForceConfirmSignup's registration_policy accessor. Covered by new test/graphql/mutations/force_confirm_signup_test.rb and update_signup_bucket_test.rb.
  4. notifier_preview_factory.rb — stopped passing a stale bucket key ("flex") where a bucket id was expected; now uses the actual signup's bucket_id. Covered by test/notifiers/notifier_preview_factory_test.rb.

N+1s

  • Preloaded :bucket/:requested_bucket before building SignupBucketFinder/FakeSignup in EventSignupService, EventChangeRegistrationPolicyService, and ExecuteRankedChoiceSignupService.
  • RegistrationPolicyBucket#signup_definitely_occupies_slot_in_bucket? now compares bucket_id directly for persisted signups against a persisted bucket, falling back to the key comparison only for FakeSignup/detached buckets (extracted into occupies_bucket_as_signup? to keep complexity in check).
  • Added :bucket to the dataloader-batched association loaders in Types::SignupType and Types::SignupChangeType (both previously called object.bucket directly, bypassing batching).
  • Preloaded buckets in EventVacancyFillService#all_signups and via SignupMoveResult#signup.

Added query-count regression tests (test/models/run_test.rb, test/services/event_signup_service_test.rb) that assert bucket-table query counts stay constant regardless of signup count — confirmed 11→2 queries and 22→4 queries before/after the fix.

Deploy risk

Split the single migration so this release only adds/backfills bucket_id/requested_bucket_id, keeping the legacy bucket_key/requested_bucket_key columns in place. Filed #11872 with the column-drop migration to run in a later release once the backfill is verified in production.

Smaller notes

Documented the withdrawn-signup preference-clearing behavior change in clear_references_to_removed_buckets, and added a comment on the synthetic bucket id in useEventForm.tsx.

Incidental cleanup

Fixing the bucket filter surfaced a shadowed Lint/MissingSuper entry in .rubocop_todo.yml (a duplicate YAML key silently dropped two exclusions) and missing id selections on several buckets{} queries flagged by @graphql-eslint/require-selections once the file was touched — fixed both. Also downgraded @graphql-eslint/no-deprecated to a warning for .graphql files, since bucket_key/requested_bucket_key are deliberately still queried in several places for frontend backward compatibility during this migration.

All 353 relevant Ruby tests pass, tsc --noEmit is clean, and rubocop/eslint report no errors on the changed files.


Comment written by Claude

@nbudin

nbudin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Manual QA plan

This PR converts bucket_key/requested_bucket_key from free-text strings to real foreign keys (bucket_id/requested_bucket_id), and this session's follow-up fixed 4 correctness bugs, several N+1s, and changed the admin Bucket column to sort by name. The plan below is organized so the highest-risk areas (registration policy edits, the admin bucket filter, force-confirm) come first, then broader regression coverage of everything else that touches buckets.

Suggested test convention: an event with a limited counted bucket (e.g. "Dogs"), an unlimited/flex bucket (e.g. "Anything"), and a run with a handful of signups split across them plus a couple of waitlisted signups.

1. Registration policy editing — bucket removal + remapping (highest risk)

This is the scenario behind the policy-change simulation bug: clearing a signup's bucket preference on a removed bucket, when the new policy has no flex bucket to fall back to.

  • On an event's edit page, in the Registration policy card, sign up an attendee with a requested bucket preference for a bucket you're about to remove.
  • Remove that bucket from the policy so the only remaining buckets are limited/counted (no "anything"/flex bucket), and save.
  • In the Remap bucket assignments modal that appears, set "Move attendees to" to No preference for the removed bucket, then Apply and save event.
  • Confirm the save succeeds (previously this could fail with "Signup for <name> would no longer fit" even though a normal-capacity bucket was available) and the attendee's signup lands in a real bucket, not left stranded.
  • Repeat, but this time map the removed bucket to a specific surviving bucket — confirm the signup moves there as expected.
  • Repeat once more with a policy that does keep a flex/anything bucket — confirm existing "moves to flex" behavior still works normally.
  • Try removing a bucket that still has pending signups/requests without providing a mapping — confirm you still get a clear "no mapping provided" error (not a crash).

2. Admin Signups table — Bucket filter and sort

Route: Event → Run → Signups tab.

  • Use the Bucket column filter to select a specific bucket. Confirm it returns the signups actually in that bucket (previously this silently returned zero rows due to a key/id mismatch).
  • Select multiple buckets in the filter and confirm rows from all selected buckets show up.
  • Click the Bucket column header to sort ascending, then descending. Confirm rows are ordered alphabetically by bucket name (case-insensitively — e.g. "anything" and "Dogs" sort by name, not by creation order/internal id), and that signups with no bucket (waitlisted) sort predictably to one end.
  • Export the table via Export CSV and confirm the Bucket column in the CSV looks correct.

3. Force-confirm and change-bucket (Edit Signup page)

Route: Signups table → click a row → Edit signup.

  • On a waitlisted signup, click Force into run, pick a bucket, and confirm it succeeds and the signup becomes confirmed in that bucket.
  • On a confirmed signup, click the change-bucket (pencil) icon next to Bucket, pick a different bucket, and confirm the move succeeds and frees up/fills the vacated bucket correctly (check another waitlisted signup gets pulled in if applicable).
  • If reachable through the UI, try submitting Force Confirm / Change Bucket without a bucket selected — confirm you get a clear error message rather than a blank page or server error. (This was previously a 500 error under the hood; if the UI always forces a selection, it's fine to note that as "not reproducible via UI" rather than force it.)

4. Self-service signup (attendee-facing)

  • Sign up for a run with a specific bucket preference — confirm you land in that bucket (or waitlist if full) and the schedule grid/status badge reflects it correctly.
  • Sign up with no preference — confirm placement follows the usual "flex bucket" / most-available-counted-bucket logic.
  • Sign up for a full bucket — confirm you're waitlisted, and that withdrawing another attendee from that bucket pulls you in automatically (vacancy fill).
  • Withdraw a confirmed signup and confirm the vacancy-fill cascade behaves as before (waitlisted attendees move in, in priority order).

5. Ranked-choice / lottery conventions (if applicable to your test convention)

  • On My Signup Queue, add and reorder a few ranked-choice preferences (drag and drop).
  • Trigger or wait for a signup round to execute (Signup Rounds admin, or the manual "rerun" action), then check Round Results — confirm decisions (confirmed/waitlisted/skipped) and bucket assignments look correct, and the results table/CSV export works.

6. Signup change history

Route: Signups tab → Change history tab.

  • Confirm the Bucket column shows correct bucket names for past changes, including for a signup whose bucket assignment involved a bucket that has since been deleted (name should still display via the historical snapshot, not blank).
  • Export the change history CSV and spot-check the Bucket column.

7. Notification preview

Route: Notifications admin (/admin_notifications).

  • Find the notification for signups moving due to a bucket change (or "user signup moved") and click Preview. Confirm the rendered preview shows a real bucket name rather than a blank/missing one.
  • Preview a couple of other signup-related notifications (confirmation, waitlist) to confirm nothing else regressed.

8. Freeze bucket assignments

Route: Signups tab → Freeze bucket assignments button.

  • Run it on a run with some flex-bucket occupants who have a requested bucket, and confirm they get moved into their requested bucket and the flex bucket shrinks accordingly, per the confirmation dialog's description.

9. GraphQL API / backward compatibility

  • In GraphiQL (or the API docs), confirm bucketKey/requestedBucketKey fields on Signup are marked deprecated but still return correct values, and the new bucket/requestedBucket object fields return the full bucket (name, id, key, etc.).
  • If you have an API client or integration still using the deprecated key-based fields, do a quick smoke test that it still works unchanged.

10. General regression pass

  • Import convention data (CSV import) that includes signups with bucket assignments — confirm buckets resolve correctly.
  • Browse the schedule grid / capacity views for a convention with several runs and confirm bucket capacity displays (full/available) are accurate — this is also a good place to eyeball page load time, since several N+1 query bugs in this area were fixed.
  • General smoke test of moderated signup requests (accept/reject with a specific bucket) if your convention uses moderated signups.

Notes for whoever picks this up

  • The four correctness bugs and N+1s each have an automated regression test now, but automated tests can't fully substitute for actually clicking through the flows above, especially the registration-policy remapping modal (§1) and the admin bucket filter/sort (§2), which are the two areas most likely to have UI-level surprises the tests didn't cover.
  • If anything in here reproduces a bug, please note which checkbox and paste any error message/screenshot in a reply.

QA plan written by Claude

BucketKeyRemappingModal is mounted once, up front, with removedBuckets:
[] before an admin ever clicks Save; removedBuckets only becomes
non-empty later, as a prop update on the already-mounted component. Its
mappings state was seeded via a useState lazy initializer, which only
ever saw that initial empty array, so a removed bucket's default "No
preference" selection (already shown as selected) never got an entry
in state unless the admin explicitly touched its dropdown. Since
handleConfirm builds bucketKeyMappings from Object.entries(mappings)
rather than from removedBuckets, accepting the default sent an empty
mapping list, and EventChangeRegistrationPolicyService correctly
rejected the change with "no mapping was provided" -- caught while
manually QAing the registration-policy-editing flow from PR #11871's
QA plan. Pre-existing bug (predates #11868/#11871), not something the
bucket FK conversion introduced.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nbudin

nbudin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Bug found in QA Section 1, fixed in ffc224b3f4

While testing the registration-policy bucket removal + remapping flow, saving with a bucket mapped to No preference (the default dropdown selection) failed with Bucket key "signups" was removed but no mapping was provided, even though the modal showed the mapping as selected.

Root cause: BucketKeyRemappingModal is mounted once, up front, with removedBuckets: [], before the admin ever clicks Save. removedBuckets only becomes non-empty later, as a prop update on that already-mounted component. Its mappings state was seeded via a useState lazy initializer, which only ever ran against that initial empty array — so a removed bucket's default "No preference" selection (already shown as selected in the dropdown) never got an entry in state unless the admin explicitly changed the dropdown. Since handleConfirm builds the submitted mappings from Object.entries(mappings) rather than from removedBuckets, accepting the default silently sent an empty list, and the (correct) server-side check rejected it.

This is a pre-existing bug in BucketKeyRemappingModal.tsx/useBucketKeyRemapping.tsx (introduced in #11229, well before #11868/#11871) — not something the bucket FK conversion caused. It just happened to block QA Section 1 of the plan above. Fixed by re-seeding mappings whenever removedBuckets changes (React's "adjusting state during render" pattern), with a regression test in test/javascript/EventAdmin/BucketKeyRemappingModal.test.tsx.

Section 1 of the QA plan should be unblocked now — worth re-testing the "map to No preference by leaving the default" case specifically, since that's exactly the path this bug broke.

Comment written by Claude

The remap modal offered "No preference" as a destination for a removed
bucket even when the new registration policy has
prevent_no_preference_signups set, which would leave affected
signups/requests/ranked choices with a null requested_bucket_id in a
state new signups aren't allowed to be created in. Caught via manual
QA on #11871.

Frontend: BucketKeyRemappingModal hides the "No preference" option and
disables Apply until every removed bucket has an explicit destination
when the new policy disallows it.

Backend: EventChangeRegistrationPolicyService now rejects a to_key:
nil mapping outright when the new policy's
prevent_no_preference_signups is set, as defense in depth for any
caller that bypasses the modal.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nbudin

nbudin commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

Second bug found in QA Section 1, fixed in bfc02b2df5

In an event whose registration policy has "no preference" signups disabled (prevent_no_preference_signups), the remap modal still let you map a removed bucket to No preference. That would leave affected signups/requests/ranked choices with a null requested_bucket_id — a state new signups aren't allowed to be created in under that policy, so existing records could drift into an inconsistent state.

Fixed on both sides:

  • Frontend: BucketKeyRemappingModal hides the "No preference" option and disables Apply until every removed bucket has an explicit destination bucket chosen, when the new policy disallows no-preference signups.
  • Backend: EventChangeRegistrationPolicyService now rejects a to_key: nil mapping outright when the new policy's prevent_no_preference_signups is set — defense in depth for anything that bypasses the modal (direct API use, future UI paths, etc.).

Both have regression tests (test/javascript/EventAdmin/BucketKeyRemappingModal.test.tsx, test/services/event_change_registration_policy_service_test.rb).

Comment written by Claude

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Report: Only Changed Files listed

Package Base Coverage New Coverage Difference
app/graphql/intercode_schema.rb 🟠 55.26% 🟠 61.84% 🟢 6.58%
app/graphql/mutations/create_my_signup.rb 🟢 94.12% 🟢 95.65% 🟢 1.53%
app/graphql/mutations/create_signup_ranked_choice.rb 🟠 68.75% 🟠 68.42% 🔴 -0.33%
app/graphql/mutations/create_signup_request.rb 🟠 61.54% 🟠 62.5% 🟢 0.96%
app/graphql/mutations/create_user_signup.rb 🟠 52.38% 🟠 55.56% 🟢 3.18%
app/graphql/mutations/force_confirm_signup.rb 🟠 66.67% 🟢 100% 🟢 33.33%
app/graphql/mutations/update_event.rb 🟢 100% 🟢 92.11% 🔴 -7.89%
app/graphql/mutations/update_signup_bucket.rb 🟠 50% 🟢 100% 🟢 50%
app/graphql/mutations/update_signup_counted.rb 🟠 60% 🟠 63.64% 🟢 3.64%
app/graphql/types/grouped_signup_count_type.rb 🟢 100% 🟢 85% 🔴 -15%
app/graphql/types/signup_change_type.rb 🟠 59.38% 🟠 60.98% 🟢 1.6%
app/graphql/types/signup_move_result_type.rb 🟢 88.89% 🟢 81.25% 🔴 -7.64%
app/graphql/types/signup_ranked_choice_type.rb 🟢 96.97% 🟢 94.59% 🔴 -2.38%
app/graphql/types/signup_request_type.rb 🟢 100% 🟢 95.83% 🔴 -4.17%
app/graphql/types/signup_type.rb 🟢 80% 🟢 78.72% 🔴 -1.28%
app/javascript/EventAdmin/BucketKeyRemappingModal.tsx 🔴 0% 🟢 100% 🟢 100%
app/liquid_drops/signup_request_drop.rb 🟢 76.92% 🟢 90% 🟢 13.08%
app/models/deprecated_graph_ql_usage.rb 🔴 0% 🟢 100% 🟢 100%
app/models/event.rb 🟢 79.12% 🟢 79.57% 🟢 0.45%
app/models/registration_policy.rb 🟢 98.55% 🟢 98.59% 🟢 0.04%
app/models/registration_policy_bucket.rb 🟢 97.92% 🟢 98.08% 🟢 0.16%
app/models/run.rb 🟢 83.78% 🟢 97.3% 🟢 13.52%
app/models/signup.rb 🟢 90% 🟢 90.28% 🟢 0.28%
app/models/signup_request.rb 🟢 95.65% 🟢 95.83% 🟢 0.18%
app/notifiers/notifier_preview_factory.rb 🟢 80% 🟢 80.39% 🟢 0.39%
app/policies/application_policy.rb 🟢 90.91% 🟢 93.94% 🟢 3.03%
app/presenters/tables/signup_changes_table_results_presenter.rb 🟢 84.62% 🟢 79.31% 🔴 -5.31%
app/presenters/tables/signups_table_results_presenter.rb 🟠 60% 🟠 67.16% 🟢 7.16%
app/presenters/tables/table_results_presenter.rb 🟢 75.22% 🟢 77.88% 🟢 2.66%
app/services/event_change_registration_policy_service.rb 🟢 88.27% 🟢 91.96% 🟢 3.69%
app/services/event_signup_service.rb 🟢 98.13% 🟢 98.17% 🟢 0.04%
app/services/import_convention_data_service.rb 🟢 97.08% 🟢 97.11% 🟢 0.03%
app/services/signup_move_result.rb 🟢 90.91% 🟢 92.31% 🟢 1.4%
test/graphql/mutations/force_confirm_signup_test.rb 🔴 0% 🟢 100% 🟢 100%
test/graphql/mutations/update_signup_bucket_test.rb 🔴 0% 🟢 100% 🟢 100%
test/notifiers/notifier_preview_factory_test.rb 🔴 0% 🟢 100% 🟢 100%
test/presenters/tables/signups_table_results_presenter_test.rb 🔴 0% 🟢 100% 🟢 100%
Overall Coverage 🟢 55.32% 🟢 55.88% 🟢 0.56%

Minimum allowed coverage is 0%, this run produced 55.88%

@nbudin
nbudin merged commit 020019e into main Aug 4, 2026
25 checks passed
@nbudin
nbudin deleted the 11868-bucket-key-foreign-keys branch August 4, 2026 20:11
nbudin added a commit that referenced this pull request Aug 6, 2026
…#signups

Sentry showed a new registration_policy_buckets-by-id N+1
(INTERCODE-186/187/189) after #11880 shipped -- it had been masked by
the much larger registration_policy/buckets N+1 that #11880 fixed,
and became the dominant N+1 once that one was gone.

Traced it to the "My Schedule" widget (the user_signups/signup_bucket_description
CMS partials, present by default on most conventions' sites), which calls
signup.bucket.name / signup.requested_bucket.name per signup.
UserConProfileDrop#signups already preloads several associations but
never picked up :bucket/:requested_bucket -- these only became real
belongs_to associations in #11871 (they used to be plain string
columns needing no extra query at all), so this gap predates #11880
but was previously hidden under the louder N+1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

database enhancement minor Bumps the minor version number on release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Convert bucket_key/requested_bucket_key to real foreign keys

1 participant